package com.oracle.coherence.patterns.security;

import com.tangosol.io.ExternalizableLite;
import com.tangosol.io.pof.PofReader;
import com.tangosol.io.pof.PofWriter;
import com.tangosol.io.pof.PortableObject;
import com.tangosol.net.CacheFactory;
import com.tangosol.net.Cluster;
import com.tangosol.net.Member;
import com.tangosol.util.Base;
import com.tangosol.util.ExternalizableHelper;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.ietf.jgss.*;
import org.jaaslounge.decoding.DecodingException;
import org.jaaslounge.decoding.kerberos.*;
import org.jaaslounge.decoding.pac.Pac;
import org.jaaslounge.decoding.pac.PacLogonInfo;
import org.jaaslounge.decoding.pac.PacSid;

import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosKey;
import java.io.*;
import java.security.Principal;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.security.Security;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * An implementation of a {@link Principal} that wraps a Kerberos ticket
 * suitable for authentication to a specific service.
 * <p/>
 * This class implements a number of different types of Serialization to allow for the different
 * methods that can be used in Coherence to serialize an Object. All of the fields of this class are
 * marked as transient as none of them are serialized by default.
 * <p/>
 * A requirement of Kerberos is that tickets for a serivce are only used once in order to stop replay
 * attacks on a service. To guarantee once-only ticket creation a ticket for the service that this
 * KrbTokenPrincipal is for is only obtained during serialization. As Coherence only serializes the
 * instance before sending over the wire this will guarantee the server recieves a new ticket.
 * The ticket must then only be authenticated once to the KDC, so this is done when the KrbTokenPrincipal
 * is deserialized on the server. A flag is then set to indicate whether authentication was successful.
 * <p/>
 *
 * @author Jonathan Knight
 */
public class KrbTicketPrincipal implements Principal, Serializable, ExternalizableLite, PortableObject {
    /**
     * The index of the token field in the POF binary stream when this KrbTokenPrincipal is serialized
     */
    public static final int POF_INDEX_TOKEN = 0;
    /**
     * The index of the userName field in the POF binary stream when this KrbTokenPrincipal is serialized
     */
    public static final int POF_INDEX_USERNAME = 1;
    /**
     * The index of the servicePrincipalName field in the POF binary stream when this KrbTokenPrincipal
     * is serialized
     */
    public static final int POF_INDEX_SPN = 2;
    /**
     * The index of the Coherence cluster member identifier for the process this KrbTokenPrincipal originated in
     * in the POF binary stream when this KrbTokenPrincipal is serialized
     */
    public static final int POF_INDEX_MEMBER = 3;

    /**
     * The name of the service this Principal authenticates to
     */
    private transient String servicePrincipalName;
    /**
     * A flag to indicate that this Principal has been authenticated
     */
    private transient boolean authenticated = false;
    /**
     * The last error that occured when authenticating this pincipal to Kerberos
     */
    private transient Throwable lastError = null;
    /**
     * A flag to indicate whether this Principal originated locally or was
     * deserialized and hence originated in a remote process.
     */
    private transient boolean local = false;
    /**
     * The username associated with this Principal
     */
    private transient String userName = null;

    /**
     * The Kerberos ticket to be used to authenticate with the service
     */
    private transient byte[] token = null;
    
    /**
     * A {@link KerberosToken} that contains information from the ticket
     */
    private transient KerberosToken kerberosToken;

    static {
        Security.addProvider(new BouncyCastleProvider());
    }

    private Set<String> groupSIDs;

    private final Object kerberosTokenLock = new Object();

    /**
     * Default no-arg constuctor for POF and ExternalizableLite
     * support. If this constructor is called in normal code it
     * will create a Principal without a valid token.
     */
    public KrbTicketPrincipal() {
    }

    /**
     * Construct a new KrbTokenPrincipal with the specified username for authentication
     * to the specified service.
     * <p/>
     *
     * @param userName             the user name of this Principal
     * @param servicePrincipalName the service that this Principal will be authenticated to.
     * @throws IllegalArgumentException if either the userName or servicePrincipalName parameters
     *                                  as null or empty Strings.
     */
    public KrbTicketPrincipal(String userName, String servicePrincipalName) {
        if (userName == null || userName.length() == 0) {
            throw new IllegalArgumentException("The userName argument cannot be null or empty string");
        }

        if (servicePrincipalName == null || servicePrincipalName.length() == 0) {
            throw new IllegalArgumentException("The servicePrincipalName argument cannot be null or empty string");
        }

        this.userName = userName;
        this.servicePrincipalName = servicePrincipalName;
    }

    /**
     * Return the username of this principal.<p/>
     *
     * @return the username of this principal.
     */
    public String getName() {
        return userName;
    }

    KerberosToken getKerberosToken() {
        return kerberosToken;
    }

    void setKerberosToken(KerberosToken kerberosToken) {
        this.kerberosToken = kerberosToken;
    }

    /**
     * Validate the tokens associated with this principal.<p/>
     * This method is called by the various deserialization methods to
     * validate the Kerberos session token.
     *
     * @param token the Kerberos session token to be validated
     */
    private void validate(final byte[] token) {
        try {
            Subject subject = KrbSecurityHelper.getCurrentSubject();
            userName = Subject.doAs(subject, new PrivilegedExceptionAction<String>() {
                public String run() throws Exception {
                    GSSManager manager = GSSManager.getInstance();
                    GSSContext context = manager.createContext((GSSCredential) null);
                    context.acceptSecContext(token, 0, token.length);

                    String name = context.getSrcName().toString();

                    Subject subject = KrbSecurityHelper.getCurrentSubject();
                    Set<KerberosKey> keys = subject.getPrivateCredentials(KerberosKey.class);

                    try {
                        KrbTicketPrincipal.this.kerberosToken = new KerberosToken(token, keys.toArray(new KerberosKey[keys.size()]));
                    } catch (Throwable _ignored) {
                        CacheFactory.log("Unable to decode KerberosToken for " + name, CacheFactory.LOG_INFO);
                        // ignored
                    }
                    return name;
                }
            });
            setAuthenticated(true);
        } catch (Exception e) {
            setLastError(e);
            setAuthenticated(false);
        }

    }

    /**
     * Obtain a new Kerberos ticket for the service principal name.
     * <p/>
     *
     * @param spn - the service principal name to get a ticket for
     * @return a Kerberos ticket for the specified service
     */
    byte[] getTokenToSerialize(final String spn) {
        Subject subject = KrbSecurityHelper.getCurrentSubject();
        byte[] serviceTicket;

        try {
            serviceTicket = Subject.doAs(subject, new PrivilegedExceptionAction<byte[]>() {
                public byte[] run() throws Exception {
                    Oid krb5Oid = new Oid("1.2.840.113554.1.2.2");
                    GSSManager manager = GSSManager.getInstance();
                    GSSName serverName = manager.createName(spn, GSSName.NT_USER_NAME);
                    GSSContext context = manager.createContext(serverName, krb5Oid, null, GSSContext.DEFAULT_LIFETIME);
                    byte[] token = new byte[0];
                    context.requestMutualAuth(false);
                    context.requestCredDeleg(false);
                    return context.initSecContext(token, 0, token.length);
                }
            });
        } catch (PrivilegedActionException e) {
            throw Base.ensureRuntimeException(e);
        }

        return serviceTicket;
    }

    /**
     * Return true if this principal is considered as valid.
     *
     * @return true if this principal is considered as valid.
     */
    public boolean isAuthenticated() {
        return authenticated;
    }

    public void setAuthenticated(boolean authenticated) {
        this.authenticated = authenticated;
    }

    public void setLocal(boolean local) {
        this.local = local;
    }

    public boolean isLocal() {
        return local;
    }

    public Throwable getLastError() {
        return lastError;
    }

    public void setLastError(Throwable lastError) {
        this.lastError = lastError;
    }

    public String getServicePrincipalName() {
        return servicePrincipalName;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return getName() + " local=" + local;
    }

    public Set<String> getGroups() {
        if (groupSIDs == null && kerberosToken != null) {
            synchronized (kerberosTokenLock) {
                Set<String> groups = new HashSet<String>();
                if (kerberosToken != null) {
                    KerberosTicket ticket = kerberosToken.getTicket();
                    if (ticket != null) {
                        KerberosEncData encData = ticket.getEncData();
                        if (encData != null) {
                            List<KerberosAuthData> authorisations = encData.getUserAuthorizations();
                            if (authorisations != null) {
                                for (KerberosAuthData auth : authorisations) {
                                    if (auth instanceof KerberosPacAuthData) {
                                        Pac pac = ((KerberosPacAuthData) auth).getPac();
                                        if (pac != null) {
                                            PacLogonInfo logon = pac.getLogonInfo();
                                            for( PacSid sid : logon.getGroupSids()) {
                                                groups.add(String.valueOf(sid));
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
                groupSIDs = groups;
            }
        }

        if (groupSIDs == null) {
            return Collections.emptySet();
        }
        return groupSIDs;
    }

    /**
     * Check this KrbTokenPrincipal for equality with the specified Object.
     * <p/>
     * To be equal the specified Object must be a KrbTokenPrincipal with the
     * same userName and servicePrincipalName as this KrbTokenPrincipal.
     * <p/>
     *
     * @param o - the object to check for equality
     * @return true if the specified Object is also a KrbTokenPrincipal with the
     *         same userName and servicePrincipalName as this KrbTokenPrincipal.
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        KrbTicketPrincipal that = (KrbTicketPrincipal) o;

        if (servicePrincipalName != null ? !servicePrincipalName.equals(that.servicePrincipalName) : that.servicePrincipalName != null) {
            return false;
        }

        if (userName != null ? !userName.equals(that.userName) : that.userName != null) {
            return false;
        }

        return true;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public int hashCode() {
        int result = servicePrincipalName != null ? servicePrincipalName.hashCode() : 0;
        result = 31 * result + (userName != null ? userName.hashCode() : 0);
        return result;
    }

    /**
     * Returns the Coherence cluster Member name for this JVM.
     * <p/>
     * If this JVM is not a cluster member this value will be set
     * to
     * <code>
     * "no member configured (Probably an Extend client)"
     * </code>
     * This method is used to get an identifier to serialze in
     * the various serialization methods. When the Principal is
     * then deserialized in the target server we can use this
     * information in debugging and logs to identify where the
     * Principal originated.
     * <p/>
     *
     * @return the Coherence cluster Member name for this JVM if this JVM is
     *         a cluster member.
     */
    String getMemberString() {
        String value;
        Cluster cluster = CacheFactory.getCluster();
        Member member = null;
        if (cluster != null && cluster.isRunning()) {
            member = cluster.getLocalMember();
        }

        if (member != null) {
            value = String.valueOf(member);
        } else {
            value = "no member configured (Probably an Extend client)";
        }
        return value;
    }

    /**
     * Standard Java serialization method to deserialize this principal.
     *
     * @param in the ObjectInputStream to deserialize from.
     * @throws IOException            if an IO error occurs
     * @throws ClassNotFoundException if any deserialized class is not found
     */
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        local = false;
        token = (byte[]) in.readObject();
        userName = in.readUTF();
        servicePrincipalName = in.readUTF();
        String member = in.readUTF();
        if (CacheFactory.isLogEnabled(CacheFactory.LOG_DEBUG)) {
            CacheFactory.log("Deserialized Java serialized KrbTicketPrincipal from member: "
                    + member, CacheFactory.LOG_DEBUG);
        }
        validate(token);
    }

    /**
     * Standard Java serialization method to serialize this principal.
     *
     * @param out - the stream to serilaize this principal to.
     * @throws IOException if an IO error occurs.
     */
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeObject(getTokenToSerialize(servicePrincipalName));
        out.writeUTF(getName());
        out.writeUTF(getServicePrincipalName());
        out.writeUTF(getMemberString());
    }

    /**
     * Coherence ExternalizableLite method to deserialize this principal.
     *
     * @param dataInput the stream to deserialize from
     * @throws IOException if any IO error occurs
     */
    public void readExternal(DataInput dataInput) throws IOException {
        local = false;
        token = ExternalizableHelper.readByteArray(dataInput);
        userName = ExternalizableHelper.readUTF(dataInput);
        servicePrincipalName = ExternalizableHelper.readUTF(dataInput);
        String member = ExternalizableHelper.readUTF(dataInput);
        if (CacheFactory.isLogEnabled(CacheFactory.LOG_DEBUG)) {
            CacheFactory.log("Deserialized Java serialized KrbTicketPrincipal from member: "
                    + member, CacheFactory.LOG_DEBUG);
        }
        validate(token);
    }

    /**
     * Coherence ExternalizableLite method to serialize this principal.
     *
     * @param dataOutput the stream to serialize to
     * @throws IOException if any IO error occurs
     */
    public void writeExternal(DataOutput dataOutput) throws IOException {
        ExternalizableHelper.writeByteArray(dataOutput, getTokenToSerialize(servicePrincipalName));
        ExternalizableHelper.writeUTF(dataOutput, getName());
        ExternalizableHelper.writeUTF(dataOutput, getServicePrincipalName());
        ExternalizableHelper.writeUTF(dataOutput, getMemberString());
    }

    /**
     * Coherence PortableObject method to serialize this principal.
     *
     * @param pofReader - the stream to serialize to
     * @throws IOException if any IO error occurs
     */
    public void readExternal(PofReader pofReader) throws IOException {
        local = false;
        token = pofReader.readByteArray(POF_INDEX_TOKEN);
        userName = pofReader.readString(POF_INDEX_USERNAME);
        servicePrincipalName = pofReader.readString(POF_INDEX_SPN);
        String member = pofReader.readString(POF_INDEX_MEMBER);
        if (CacheFactory.isLogEnabled(CacheFactory.LOG_DEBUG)) {
            CacheFactory.log("Deserialized Java serialized KrbTicketPrincipal from member: "
                    + member, CacheFactory.LOG_DEBUG);
        }
        validate(token);
    }

    /**
     * Coherence PortableObject method to deserialize this principal.
     *
     * @param pofWriter - the stream to deserialize from
     * @throws IOException if any IO error occurs
     */
    public void writeExternal(PofWriter pofWriter) throws IOException {
        pofWriter.writeByteArray(POF_INDEX_TOKEN, getTokenToSerialize(servicePrincipalName));
        pofWriter.writeString(POF_INDEX_USERNAME, getName());
        pofWriter.writeString(POF_INDEX_SPN, getServicePrincipalName());
        pofWriter.writeString(POF_INDEX_MEMBER, getMemberString());
    }
}